package za.org.jcicada.katcp;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.ClosedByInterruptException;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public abstract class Client2 {

    /** Class logger. */
    private static final Log logger = LogFactory.getLog(Client2.class);

    /** Decoder for decoding incoming message strings. */
    protected static CharsetDecoder wireDecoder = Charset.forName("ascii").newDecoder();

    /** Encoder for encoding outgoing message strings. */
    protected static CharsetEncoder wireEncoder = Charset.forName("ascii").newEncoder();

    /** Parser instance for parsing strings into Message objects. */
    protected Parser parser = new Parser();

    /** Channel to server. */
    protected SocketChannel sc = null;
    
    /** Thread used for reading from socket. */
    private SocketRunner socketRunner;

    /** Host to connect to. */
    protected String host;

    /** Port to connect to. */
    protected int port;

    /**
     * Create a KATCP client.
     * 
     * @param host The host to connect to.
     * @param port The port to connect to.
     */
    public Client2(String host, int port) {
        this.host = host;
        this.port = port;
    }

    /**
     * Connect or reconnect to the server.
     * 
     * @throws IOException if the connect fails.
     */
    public void connect() throws IOException {
        close();
        sc = SocketChannel.open(new InetSocketAddress(host, port));
        sc.configureBlocking(false);
        socketRunner = new SocketRunner();
        socketRunner.start();
    }

    /**
     * If open then close connection to the server.
     * 
     */
    public void close() {
        if (socketRunner != null) {
            socketRunner.terminate();
            socketRunner = null;
        }
        if (sc != null) {
            try {
                sc.close();
            } catch (IOException ex) {
                logger.error("Error closing socket", ex);
            }
            sc = null;
        }
    }

    /**
     * Check if the connection is currently open.
     * 
     * @return true if connected to the server
     */
    public boolean isConnected() {
        return false;
    }

    /**
     * Send a message to the server.
     * 
     * @param msg The Message to send to the server.
     * @throws IOException If the send fails.
     */
    public void sendMessage(Message msg) throws IOException {
        ByteBuffer buf = wireEncoder.encode(CharBuffer.wrap(msg.toString() + "\n"));
        if (sc == null) {
        	throw new IOException("Connection closed");
        }
        sc.write(buf);
    }

    /**
     * Send a request message.
     * 
     * @param msg The request Message to send to the server.
     * @throws IOException If the send fails.
     */
    public void request(Message msg) throws IOException {
        if (msg.getType() != Message.Type.REQUEST)
            throw new RuntimeException("Client.request() should only be used to send request messages. msg: " + msg.toString());
        sendMessage(msg);
    }

    /**
     * Called upon disconnection of the client. Should be overridden in a
     * derived class to handle this event.
     *
     * @param reason string describing why the connection closed
     */
    protected abstract void onDisconnect(String reason);
    
    private void onDisconnectInternal(String reason) {
        close();
        onDisconnect(reason);
    }

    /**
     * Handle a chunk of data from the server.
     * 
     * @param chunk The data from the server to process.
     * @return Any unprocessed data that remains.
     */
    private String handleChunk(String chunk) {
        chunk = chunk.replace('\r', '\n');
        String[] lines = chunk.split("\\n", -1);
        String fullLine;
        Message msg;

        for (int i = 0; i < lines.length - 1; i++) {
            fullLine = lines[i];
            try {
                msg = parser.parse(fullLine);
                handleMessage(msg);
            } catch (Exception e) {
                // usually the parser will throw Parser.KatcpSyntaxExceptions
                // but we really do want to catch other errors too.
                StringWriter sw = new StringWriter();
                e.printStackTrace(new PrintWriter(sw));
                logger.error(String.format("Error handling message from server ['%s']", sw));
            }
        }

        return lines[lines.length - 1];
    }

    /**
     * Handles a received message.
     * 
     * @param message the message to handle
     */
    @SuppressWarnings("unchecked")
	private void handleMessage(Message message) {
        String methodName = null;

        try {
            StringBuffer buffer = new StringBuffer(message.msgType.toString().toLowerCase());
            for (String part: message.getName().split("-")) {
                buffer.append(part.substring(0, 1).toUpperCase());
                buffer.append(part.substring(1));
            }
            methodName = buffer.toString();
            Class[] params = { message.getClass() };
            Method m = null;
            m = getClass().getMethod(methodName, params);
            logger.debug(String.format("Dispatching call to handler: %s", methodName));
            m.invoke(this, message);
        } catch (NoSuchMethodException ex) {
            logger.error("No handler for method " + methodName);
        } catch (Exception ex) {
            if (methodName == null) {
                logger.error("Error determining name of method to call.", ex);
            } else {
                logger.error("Error calling handler method " + methodName + ".", ex);
            }
        }
    }
    
    private class SocketRunner extends BaseRunner {

        @Override
        protected void strategy() throws InterruptedException {
            Selector selector = null;
            SocketChannel tmpChannel;
            ByteBuffer buffer = ByteBuffer.allocate(4096);
            CharBuffer cbuffer = CharBuffer.allocate(4096);
            String chunk = "";

            try {
                selector = Selector.open();
                sc.register(selector, SelectionKey.OP_READ);

                while (true) {
                    if (selector.select() == 0) {
                        // no keys so we have been interrupted
                        throw new InterruptedException();
                    }
    
                    for (SelectionKey key: selector.selectedKeys()) {
                        tmpChannel = (SocketChannel) key.channel();
    
                        buffer.clear();
                        try {
                            tmpChannel.read(buffer);
                        } catch (ClosedByInterruptException ex) {
                            throw new InterruptedException();
                        }
    
                        buffer.flip();
                        if (buffer.limit() == 0) {
                            // read flag with no data == server closed
                            onDisconnectInternal("Connection reset by peer");
                            break;
                        }
    
                        cbuffer.clear();
                        wireDecoder.decode(buffer, cbuffer, true);
                        cbuffer.flip();
                        chunk = handleChunk(chunk + cbuffer.toString());
                    }
    
                    selector.selectedKeys().clear();
                }
            }
            catch (IOException ex) {
                StringWriter sw = new StringWriter();
                ex.printStackTrace(new PrintWriter(sw));
                onDisconnectInternal(sw.toString());
            } finally {
                if (selector != null) {
                    try {
                        selector.close();
                    } catch (IOException ex) {
                        // TODO something here!
                    }
                }
            }

        }
        
    }

}
